SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
5.2 KB · 107 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { t } from '@/i18n';5import { api, isNotBuilt, safe } from '@/lib/api';6import { formatDate } from '@/lib/format';7import { jsonLd, seoTitle } from '@/lib/seo';8import { routes } from '@/lib/site';9import { STORIES, storyBySlug, storyChartCount, storyIndicators } from '@/lib/stories';10import { topicById } from '@/lib/topics';11import { NotBuiltState } from '@/components/data/empty-state';12import { PageHeader } from '@/components/explore/page-header';13import { JsonLd } from '@/components/platform/json-ld';14import { loadStoryData } from '@/components/stories/resolve';15import { StoryBlocks } from '@/components/stories/story-blocks';1617export const revalidate = 3600;1819type Params = { slug: string };2021export function generateStaticParams(): Params[] {22  return STORIES.map((s) => ({ slug: s.slug }));23}2425export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {26  const { slug } = await params;27  const s = storyBySlug(slug);28  if (!s) return { title: t('stories.notFound'), robots: { index: false } };29  const title = seoTitle.story(s.title);30  const canonical = routes.story(s.slug);31  return {32    title,33    description: s.standfirst,34    alternates: { canonical },35    openGraph: { title: `${title} — ${t('site.name')}`, description: s.standfirst, url: canonical, type: 'article', publishedTime: s.published },36    twitter: { card: 'summary_large_image', title, description: s.standfirst },37  };38}3940export default async function StoryPage({ params }: { params: Promise<Params> }) {41  const { slug } = await params;42  const story = storyBySlug(slug);43  if (!story) notFound();44  let health;45  try {46    health = await api.health();47  } catch (e) {48    if (isNotBuilt(e)) return <NotBuiltState />;49    throw e;50  }51  if (health.status === 'empty') return <NotBuiltState />;52  const data = await loadStoryData(story);53  const indicators = storyIndicators(story);54  const indicatorRes = await Promise.all(indicators.map((s) => safe(api.indicatorMap(s, {}))));55  const indicatorNames = indicators.map((s, i) => ({ slug: s, name: indicatorRes[i]?.indicator.name ?? s }));56  const sources = Array.from(new Set(indicatorRes.map((m) => m?.provenance?.source_name).filter((x): x is string => !!x)));57  const others = STORIES.filter((s) => s.slug !== story.slug);58  const topics = story.topics.map((tp) => topicById(tp)?.short ?? tp);5960  return (61    <>62      <JsonLd data={[jsonLd.article({ slug: story.slug, title: story.title, description: story.standfirst, published: story.published, modified: health.built_at }), jsonLd.breadcrumbs([{ name: t('site.name'), path: '/' }, { name: t('stories.title'), path: routes.stories() }, { name: story.title, path: routes.story(story.slug) }])]} />63      <PageHeader crumbs={[{ href: routes.stories(), label: t('stories.title') }]} eyebrow={`${t('stories.eyebrow')} · ${topics.join(' · ')}`} title={story.title} lede={story.standfirst} meta={`${t('stories.charts', { n: storyChartCount(story) })} · ${t('stories.minutes', { n: Math.max(2, Math.round(storyChartCount(story) * 0.8)) })} · ${t('site.footer.refreshed', { date: formatDate(health.built_at) })}`} />6465      <article className="mt-4 md:mt-6">66        <StoryBlocks story={story} data={data} />67      </article>6869      <section className="hairline mt-12 pt-6" aria-labelledby="story-sources-h">70        <h2 id="story-sources-h" className="display text-xl text-ink md:text-2xl">71          {t('stories.sources')}72        </h2>73        <p className="mt-2 max-w-prose text-sm leading-relaxed text-ink-2">{t('stories.sourcesNote', { run: health.run_id ?? '', date: formatDate(health.built_at) })}</p>74        {sources.length ? <p className="mt-1 text-sm text-ink-2">{sources.join(' · ')}</p> : null}75        <h3 className="mt-4 text-sm font-semibold text-ink">{t('stories.indicators')}</h3>76        <ul className="mt-1 flex flex-wrap gap-1.5">77          {indicatorNames.map((i) => (78            <li key={i.slug}>79              <Link href={routes.indicator(i.slug)} className="inline-flex min-h-[36px] items-center rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-accent hover:text-accent">80                {i.name}81              </Link>82            </li>83          ))}84        </ul>85        <h3 className="mt-5 text-sm font-semibold text-ink">{t('stories.method')}</h3>86        <p className="mt-1 max-w-prose text-sm leading-relaxed text-ink-3">{t('stories.methodText')}</p>87      </section>8889      <section className="hairline mt-10 pt-6 pb-10" aria-labelledby="story-more-h">90        <h2 id="story-more-h" className="display text-xl text-ink md:text-2xl">91          {t('stories.more')}92        </h2>93        <ul className="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">94          {others.map((s) => (95            <li key={s.slug} className="border-t border-rule">96              <Link href={routes.story(s.slug)} className="group flex min-h-[64px] flex-col justify-center py-3">97                <span className="text-sm font-semibold text-ink group-hover:text-accent">{s.title}</span>98                <span className="mt-0.5 line-clamp-2 text-xs text-ink-2">{s.standfirst}</span>99              </Link>100            </li>101          ))}102        </ul>103      </section>104    </>105  );106}107